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 Kredis, ActionCable etc.
 - 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 `--no-devcontainer` option.

Co-authored-by: Rafael Mendonça França <rafael@franca.dev>
  • Loading branch information
andrewn617 and rafaelfranca committed Feb 14, 2024
1 parent dad6511 commit 72f12f0
Show file tree
Hide file tree
Showing 21 changed files with 746 additions and 3 deletions.
2 changes: 1 addition & 1 deletion actionpack/lib/action_dispatch/system_test_case.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def self.driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: {
self.driver = SystemTesting::Driver.new(driver, **driver_options, &capabilities)
end

# Configuration for the System Test application server
# Configuration for the System Test application server.
#
# By default this is localhost. This method allows the host and port to be specified manually.
def self.served_by(host:, port:)
Expand Down
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 Kredis, ActionCable etc.
- 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*

* Introduce `SystemTestCase#served_by` for configuring the System Test application server

By default this is localhost. This method allows the host and port to be specified manually.
Expand Down
1 change: 1 addition & 0 deletions railties/lib/rails/generators.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module Generators
autoload :NamedBase, "rails/generators/named_base"
autoload :ResourceHelpers, "rails/generators/resource_helpers"
autoload :TestCase, "rails/generators/test_case"
autoload :Devcontainer, "rails/generators/devcontainer"

mattr_accessor :namespace

Expand Down
8 changes: 8 additions & 0 deletions railties/lib/rails/generators/app_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module Rails
module Generators
class AppBase < Base # :nodoc:
include Database
include Devcontainer
include AppName

NODE_LTS_VERSION = "18.15.0"
Expand Down Expand Up @@ -109,6 +110,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: false,
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 +404,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
8 changes: 8 additions & 0 deletions railties/lib/rails/generators/database.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ def mysql_socket
"/opt/lampp/var/mysql/mysql.sock" # xampp for linux
].find { |f| File.exist?(f) } unless Gem.win_platform?
end

def mysql_database_host
if options[:skip_devcontainer]
"localhost"
else
"<%= ENV.fetch(\"DB_HOST\") { \"localhost\" } %>"
end
end
end
end
end
125 changes: 125 additions & 0 deletions railties/lib/rails/generators/devcontainer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true

module Rails
module Generators
module Devcontainer
private
def devcontainer_dependencies
return @devcontainer_dependencies if @devcontainer_dependencies

@devcontainer_dependencies = []

@devcontainer_dependencies << "selenium" if depends_on_system_test?
@devcontainer_dependencies << "redis" if devcontainer_needs_redis?
@devcontainer_dependencies << db_name_for_devcontainer if db_name_for_devcontainer
@devcontainer_dependencies
end

def devcontainer_variables
return @devcontainer_variables if @devcontainer_variables

@devcontainer_variables = {}

@devcontainer_variables["CAPYBARA_SERVER_PORT"] = "45678" if depends_on_system_test?
@devcontainer_variables["SELENIUM_HOST"] = "selenium" if depends_on_system_test?
@devcontainer_variables["REDIS_URL"] = "redis://redis:6379/1" if devcontainer_needs_redis?
@devcontainer_variables["DB_HOST"] = db_name_for_devcontainer if db_name_for_devcontainer

@devcontainer_variables
end

def devcontainer_volumes
return @devcontainer_volumes if @devcontainer_volumes

@devcontainer_volumes = []

@devcontainer_volumes << "redis-data" if devcontainer_needs_redis?
@devcontainer_volumes << db_volume_name_for_devcontainer if db_volume_name_for_devcontainer

@devcontainer_volumes
end

def devcontainer_needs_redis?
!(options.skip_action_cable? && options.skip_active_job?)
end

def db_name_for_devcontainer(database = options[:database])
case database
when "mysql" then "mysql"
when "trilogy" then "mariadb"
when "postgresql" then "postgres"
end
end

def db_volume_name_for_devcontainer(database = options[:database])
case database
when "mysql" then "mysql-data"
when "trilogy" then "mariadb-data"
when "postgresql" then "postgres-data"
end
end

def devcontainer_db_service_yaml(**options)
return unless service = db_service_for_devcontainer

service.to_yaml(**options)[4..-1]
end

def db_service_for_devcontainer(database = options[:database])
case database
when "mysql" then mysql_service
when "trilogy" then mariadb_service
when "postgresql" then postgres_service
end
end

def postgres_service
{
"postgres" => {
"image" => "postgres:16.1",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["postgres-data:/var/lib/postgresql/data"],
"environment" => {
"POSTGRES_USER" => "postgres",
"POSTGRES_PASSWORD" => "postgres"
}
}
}
end

def mysql_service
{
"mysql" => {
"image" => "mysql/mysql-server:8.0",
"restart" => "unless-stopped",
"environment" => {
"MYSQL_ALLOW_EMPTY_PASSWORD" => true,
"MYSQL_ROOT_HOST" => "%"
},
"volumes" => ["mysql-data:/var/lib/mysql"],
"networks" => ["default"],
}
}
end

def mariadb_service
{
"mariadb" => {
"image" => "mariadb:10.5",
"restart" => "unless-stopped",
"networks" => ["dqefault"],
"volumes" => ["mariadb-data:/var/lib/mysql"],
"environment" => {
"MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true,
},
}
}
end

def db_service_names
["mysql", "mariadb", "postgres"]
end
end
end
end
13 changes: 13 additions & 0 deletions railties/lib/rails/generators/rails/app/app_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,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/compose.yaml"
end
end

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

def create_devcontainer_files
return if skip_devcontainer? || options[:dummy_app]
build(:devcontainer)
end

def delete_app_assets_if_api_option
if options[:api]
remove_dir "app/assets"
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,55 @@
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 -%>

<%- if devcontainer_needs_redis? -%>
redis:
image: redis:7.2
restart: unless-stopped
networks:
- default
volumes:
- redis-data:/data
<%- end -%>

<%= devcontainer_db_service_yaml(indentation: 4) %>

<%- if !devcontainer_volumes.empty? -%>
volumes:
<%- devcontainer_volumes.each do |volume| -%>
<%= volume %>:
<%- end -%>
<%- end -%>
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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": "<%= app_name %>",
"dockerComposeFile": "compose.yaml",
"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": {}
},

<%- if !devcontainer_variables.empty? -%>
"containerEnv": {
<%= devcontainer_variables.map { |key, value| "\"#{key}\": \"#{value}\"" }.join(",\n ") %>
},
<%- 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": [],

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

// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root",

// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bin/setup"
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ default: &default
<% if mysql_socket -%>
socket: <%= mysql_socket %>
<% else -%>
host: localhost
host: <%= mysql_database_host %>
<% end -%>

development:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ default: &default
# For details on connection pooling, see Rails configuration guide
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
<% unless options[:skip_devcontainer] -%>
<%% if ENV["DB_HOST"] %>
host: <%%= ENV["DB_HOST"] %>
username: postgres
password: postgres
<%% end %>
<% end %>

development:
<<: *default
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ default: &default
<% if mysql_socket -%>
socket: <%= mysql_socket %>
<% else -%>
host: localhost
host: <%= mysql_database_host %>
<% end -%>

development:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
<% if skip_devcontainer? -%>
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
<% else -%>
if ENV["CAPYBARA_SERVER_PORT"]
served_by host: "rails-app", port: ENV["CAPYBARA_SERVER_PORT"]

driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ], options: {
browser: :remote,
url: "http://#{ENV["SELENIUM_HOST"]}:4444",
}
else
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
end
<% end -%>
end
Loading

0 comments on commit 72f12f0

Please sign in to comment.