Skip to content

Commit

Permalink
Generate a .devcontainer folder and its contents when creating a new …
Browse files Browse the repository at this point in the history
…app.

The devcontainer folder includes everything needed to boot the app and do development in a remote container.

The container setup includes:
 - A redis container for Sidekiq and Action Cable
 - A database (SQLite, Postgres, MySQL or MariaDB)
 - A Headless chrome container for system tests
 - Active Storage configured to use the local disk and with preview features working

If any of these options are skipped in the app setup they will not be included in the container configuration.

Co-authored-by: Rafael Mendonça França <rafael@franca.dev>
  • Loading branch information
andrewn617 and rafaelfranca committed Jan 29, 2024
1 parent 1dfb1d4 commit a60e8f5
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 0 deletions.
16 changes: 16 additions & 0 deletions railties/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
* Generate a .devcontainer folder and its contents when creating a new app.

The .devcontainer folder includes everything needed to boot the app and do development in a remote container.

The container setup includes:
- A redis container for Sidekiq and Action Cable
- A database (SQLite, Postgres, MySQL or MariaDB)
- A Headless chrome container for system tests
- Active Storage configured to use the local disk and with preview features working

If any of these options are skipped in the app setup they will not be included in the container configuration.

These files can be skipped using the `--skip_devcontainer` option.

*Andrew Novoselac & Rafael Mendonça França*

* Rails console now indicates application name and the current Rails environment:

```txt
Expand Down
7 changes: 7 additions & 0 deletions railties/lib/rails/generators/app_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ def self.add_shared_options_for(name)
class_option :skip_ci, type: :boolean, default: nil,
desc: "Skip GitHub CI files"

class_option :skip_devcontainer, type: :boolean, default: nil,
desc: "Skip devcontainer files"

class_option :dev, type: :boolean, default: nil,
desc: "Set up the #{name} with Gemfile pointing to your Rails checkout"

Expand Down Expand Up @@ -400,6 +403,10 @@ def skip_ci?
options[:skip_ci]
end

def skip_devcontainer?
options[:skip_devcontainer]
end

class GemfileEntry < Struct.new(:name, :version, :comment, :options, :commented_out)
def initialize(name, version, comment, options = {}, commented_out = false)
super
Expand Down
27 changes: 27 additions & 0 deletions railties/lib/rails/generators/rails/app/app_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,14 @@ def vendor
def config_target_version
@config_target_version || Rails::VERSION::STRING.to_f
end

def devcontainer
empty_directory ".devcontainer"

template ".devcontainer/devcontainer.json"
template ".devcontainer/Dockerfile"
template ".devcontainer/docker-compose.yml"
end
end

module Generators
Expand Down Expand Up @@ -457,6 +465,11 @@ def create_storage_files
build(:storage)
end

def create_devcontainer_files
return if skip_devcontainer?
build(:devcontainer)
end

def delete_app_assets_if_api_option
if options[:api]
remove_dir "app/assets"
Expand Down Expand Up @@ -595,6 +608,20 @@ def after_bundle(&block) # :doc:
def get_builder_class
defined?(::AppBuilder) ? ::AppBuilder : Rails::AppBuilder
end

def devcontainer_dependencies
return @devcontainer_dependencies if @devcontainer_dependencies

@devcontainer_dependencies = []

@devcontainer_dependencies << "selenium" if depends_on_system_test?
@devcontainer_dependencies << "redis" unless options.skip_action_cable? && options.skip_active_job?
@devcontainer_dependencies << "postgres" if options.database == "postgresql"
@devcontainer_dependencies << "mysql" if options.database == "mysql"
@devcontainer_dependencies << "mariadb" if options.database == "trilogy"

@devcontainer_dependencies
end
end

# This class handles preparation of the arguments before the AppGenerator is
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/devcontainers/ruby:1-3-bookworm

<%- unless options.skip_active_storage -%>
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
libvips \
# For video thumbnails
ffmpeg \
# For pdf thumbnails. If you want to use mupdf instead of poppler,
# you can install the following packages instead:
# mupdf mupdf-tools
poppler-utils
<%- end -%>
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/ruby
{
"name": "Ruby",
"dockerComposeFile": "docker-compose.yml",
"service": "rails-app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},

"containerEnv": {
<%- if depends_on_system_test? -%>
"CAPYBARA_SERVER_PORT": "45678",
<%- end -%>
<%- unless options.skip_active_job? -%>
"JOBS_REDIS_URL": "redis://redis:6379/1",
<%- end -%>
<%- unless options.skip_action_cable? -%>
"CABLE_REDIS_URL": "redis://redis:6379/1",
<%- end -%>
<%- if options.database == "postgresql" -%>
"RAILS_DATABASE_HOST": "postgres",
<%- end -%>
<%- if options.database == "mysql" -%>
"RAILS_DATABASE_HOST": "mysql",
<%- end -%>
<%- if options.database == "trilogy" -%>
"RAILS_DATABASE_HOST": "mariadb",
<%- end -%>
},

// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},

// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],

// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bin/setup"

// Configure tool-specific properties.
// "customizations": {},

// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
services:
rails-app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile

volumes:
- ../..:/workspaces:cached

# Overrides default command so things don't shut down after the process ends.
command: sleep infinity

networks:
- default

# Uncomment the next line to use a non-root user for all processes.
# user: vscode

# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
ports:
- 45678:45678
<%- if !devcontainer_dependencies.empty? -%>
depends_on:
<%- devcontainer_dependencies.each do |dependency| -%>
- <%= dependency %>
<%- end -%>
<%- end -%>

<%- if depends_on_system_test? -%>
selenium:
image: seleniarm/standalone-chromium
restart: unless-stopped
networks:
- default
<%- end -%>

<%- unless options.skip_active_job? && options.skip_action_cable? -%>
redis:
image: redis:7.2
restart: unless-stopped
networks:
- default
ports:
- 6379:6379
<%- end -%>

<%- if options.database == "postgresql" -%>
postgres:
image: postgres:16.1
restart: unless-stopped
networks:
- default
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
<%- end -%>

<%- if options.database == "mysql" -%>
mysql:
image: mysql/mysql-server:8.0
restart: unless-stopped
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: true
MYSQL_ROOT_HOST: "%"
networks:
- default
<%- end -%>

<%- if options.database == "trilogy" -%>
mariadb:
image: mariadb:10.5
restart: unless-stopped
networks:
- default
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: true
<%- end -%>
127 changes: 127 additions & 0 deletions railties/test/generators/app_generator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,133 @@ def test_name_option
assert_file "config/application.rb", /^module MyApp$/
end

def test_devcontainer
run_generator

assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/JOBS_REDIS_URL/, content)
assert_match(/CABLE_REDIS_URL/, content)
assert_match(/CAPYBARA_SERVER_PORT/, content)
end
assert_file(".devcontainer/Dockerfile") do |content|
assert_match(/libvips/, content)
assert_match(/ffmpeg/, content)
assert_match(/poppler-utils/, content)
end
assert_file(".devcontainer/docker-compose.yml") do |content|
assert_match(/depends_on:/, content)
assert_match(/- selenium/, content)
assert_match(/selenium:/, content)
assert_match(/redis:/, content)
assert_match(/- redis/, content)
assert_no_match(/- maria_db/, content)
assert_no_match(/maria_db:/, content)
assert_no_match(/- postgres/, content)
assert_no_match(/postgres:/, content)
assert_no_match(/- mysql/, content)
assert_no_match(/mysql:/, content)
end
end

def test_devcontainer_no_redis_skipping_action_cable_and_active_job
run_generator [ destination_root, "--skip_action_cable", "--skip_active_job" ]

assert_file(".devcontainer/docker-compose.yml") do |content|
assert_no_match(/- redis/, content)
assert_no_match(/redis:/, content)
end
end

def test_devcontainer_no_jobs_redis_url_when_skipping_active_job
run_generator [ destination_root, "--skip_active_job" ]

assert_file(".devcontainer/devcontainer.json") do |content|
assert_no_match(/JOBS_REDIS_URL/, content)
end
end

def test_devcontainer_no_cable_redis_url_when_skipping_action_cable
run_generator [ destination_root, "--skip_action_cable" ]

assert_file(".devcontainer/devcontainer.json") do |content|
assert_no_match(/CABLE_REDIS_URL/, content)
end
end

def test_devonctainer_postgresql
run_generator [ destination_root, "-d", "postgresql" ]

assert_file(".devcontainer/docker-compose.yml") do |content|
assert_match(/- postgres/, content)
assert_match(/postgres:/, content)
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"RAILS_DATABASE_HOST": "postgres"/, content)
end
end

def test_devonctainer_mysql
run_generator [ destination_root, "-d", "mysql" ]

assert_file(".devcontainer/docker-compose.yml") do |content|
assert_match(/- mysql/, content)
assert_match(/mysql:/, content)
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"RAILS_DATABASE_HOST": "mysql"/, content)
end
end

def test_devonctainer_mariadb
run_generator [ destination_root, "-d", "trilogy" ]

assert_file(".devcontainer/docker-compose.yml") do |content|
assert_match(/- mariadb/, content)
assert_match(/mariadb:/, content)
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"RAILS_DATABASE_HOST": "mariadb"/, content)
end
end

def test_devcontainer_no_selenium_when_skipping_system_test
run_generator [ destination_root, "--skip_system_test" ]

assert_file(".devcontainer/docker-compose.yml") do |content|
assert_no_match(/- selenium/, content)
assert_no_match(/selenium:/, content)
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_no_match(/CAPYBARA_SERVER_PORT/, content)
end
end

def test_devcontainer_no_Dockerfile_packages_when_skipping_active_storage
run_generator [ destination_root, "--skip_active_storage" ]

assert_file(".devcontainer/Dockerfile") do |content|
assert_no_match(/libvips/, content)
assert_no_match(/ffmpeg/, content)
assert_no_match(/poppler-utils/, content)
end
end

def test_devcontainer_no_depends_on_when_no_dependencies
run_generator [ destination_root, "--minimal" ]

assert_file(".devcontainer/docker-compose.yml") do |content|
assert_no_match(/depends_on:/, content)
end
end

def test_skip_devcontainer
run_generator [ destination_root, "--skip_devcontainer" ]

assert_no_file(".devcontainer/devcontainer.json")
assert_no_file(".devcontainer/Dockerfile")
assert_no_file(".devcontainer/docker-compose.yml")
end

private
def assert_node_files
assert_file ".node-version" do |content|
Expand Down

0 comments on commit a60e8f5

Please sign in to comment.