Skip to content

Commit c90a870

Browse files
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 `--no-devcontainer` option. Co-authored-by: Rafael Mendonça França <[email protected]>
1 parent 30506d2 commit c90a870

22 files changed

+761
-6
lines changed

actionpack/lib/action_dispatch/system_test_case.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def self.driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: {
161161
self.driver = SystemTesting::Driver.new(driver, **driver_options, &capabilities)
162162
end
163163

164-
# Configuration for the System Test application server
164+
# Configuration for the System Test application server.
165165
#
166166
# By default this is localhost. This method allows the host and port to be specified manually.
167167
def self.served_by(host:, port:)

railties/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
* Generate a .devcontainer folder and its contents when creating a new app.
2+
3+
The .devcontainer folder includes everything needed to boot the app and do development in a remote container.
4+
5+
The container setup includes:
6+
- A redis container for Kredis, ActionCable etc.
7+
- A database (SQLite, Postgres, MySQL or MariaDB)
8+
- A Headless chrome container for system tests
9+
- Active Storage configured to use the local disk and with preview features working
10+
11+
If any of these options are skipped in the app setup they will not be included in the container configuration.
12+
13+
These files can be skipped using the `--skip-devcontainer` option.
14+
15+
*Andrew Novoselac & Rafael Mendonça França*
16+
117
* Introduce `SystemTestCase#served_by` for configuring the System Test application server
218

319
By default this is localhost. This method allows the host and port to be specified manually.

railties/lib/rails/generators.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module Generators
2323
autoload :NamedBase, "rails/generators/named_base"
2424
autoload :ResourceHelpers, "rails/generators/resource_helpers"
2525
autoload :TestCase, "rails/generators/test_case"
26+
autoload :Devcontainer, "rails/generators/devcontainer"
2627

2728
mattr_accessor :namespace
2829

railties/lib/rails/generators/app_base.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module Rails
1313
module Generators
1414
class AppBase < Base # :nodoc:
1515
include Database
16+
include Devcontainer
1617
include AppName
1718

1819
NODE_LTS_VERSION = "18.15.0"
@@ -109,6 +110,9 @@ def self.add_shared_options_for(name)
109110
class_option :skip_ci, type: :boolean, default: nil,
110111
desc: "Skip GitHub CI files"
111112

113+
class_option :skip_devcontainer, type: :boolean, default: false,
114+
desc: "Skip devcontainer files"
115+
112116
class_option :dev, type: :boolean, default: nil,
113117
desc: "Set up the #{name} with Gemfile pointing to your Rails checkout"
114118

@@ -400,6 +404,10 @@ def skip_ci?
400404
options[:skip_ci]
401405
end
402406

407+
def skip_devcontainer?
408+
options[:skip_devcontainer]
409+
end
410+
403411
class GemfileEntry < Struct.new(:name, :version, :comment, :options, :commented_out)
404412
def initialize(name, version, comment, options = {}, commented_out = false)
405413
super

railties/lib/rails/generators/database.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ def mysql_socket
9090
"/opt/lampp/var/mysql/mysql.sock" # xampp for linux
9191
].find { |f| File.exist?(f) } unless Gem.win_platform?
9292
end
93+
94+
def mysql_database_host
95+
if options[:skip_devcontainer]
96+
"localhost"
97+
else
98+
"<%= ENV.fetch(\"DB_HOST\") { \"localhost\" } %>"
99+
end
100+
end
93101
end
94102
end
95103
end
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# frozen_string_literal: true
2+
3+
module Rails
4+
module Generators
5+
module Devcontainer
6+
private
7+
def devcontainer_ruby_version
8+
gem_ruby_version.to_s.match(/^\d+\.\d+/).to_s
9+
end
10+
11+
def devcontainer_dependencies
12+
return @devcontainer_dependencies if @devcontainer_dependencies
13+
14+
@devcontainer_dependencies = []
15+
16+
@devcontainer_dependencies << "selenium" if depends_on_system_test?
17+
@devcontainer_dependencies << "redis" if devcontainer_needs_redis?
18+
@devcontainer_dependencies << db_name_for_devcontainer if db_name_for_devcontainer
19+
@devcontainer_dependencies
20+
end
21+
22+
def devcontainer_variables
23+
return @devcontainer_variables if @devcontainer_variables
24+
25+
@devcontainer_variables = {}
26+
27+
@devcontainer_variables["CAPYBARA_SERVER_PORT"] = "45678" if depends_on_system_test?
28+
@devcontainer_variables["SELENIUM_HOST"] = "selenium" if depends_on_system_test?
29+
@devcontainer_variables["REDIS_URL"] = "redis://redis:6379/1" if devcontainer_needs_redis?
30+
@devcontainer_variables["DB_HOST"] = db_name_for_devcontainer if db_name_for_devcontainer
31+
32+
@devcontainer_variables
33+
end
34+
35+
def devcontainer_volumes
36+
return @devcontainer_volumes if @devcontainer_volumes
37+
38+
@devcontainer_volumes = []
39+
40+
@devcontainer_volumes << "redis-data" if devcontainer_needs_redis?
41+
@devcontainer_volumes << db_volume_name_for_devcontainer if db_volume_name_for_devcontainer
42+
43+
@devcontainer_volumes
44+
end
45+
46+
def devcontainer_needs_redis?
47+
!(options.skip_action_cable? && options.skip_active_job?)
48+
end
49+
50+
def db_name_for_devcontainer(database = options[:database])
51+
case database
52+
when "mysql" then "mysql"
53+
when "trilogy" then "mariadb"
54+
when "postgresql" then "postgres"
55+
end
56+
end
57+
58+
def db_volume_name_for_devcontainer(database = options[:database])
59+
case database
60+
when "mysql" then "mysql-data"
61+
when "trilogy" then "mariadb-data"
62+
when "postgresql" then "postgres-data"
63+
end
64+
end
65+
66+
def devcontainer_db_service_yaml(**options)
67+
return unless service = db_service_for_devcontainer
68+
69+
service.to_yaml(**options)[4..-1]
70+
end
71+
72+
def db_service_for_devcontainer(database = options[:database])
73+
case database
74+
when "mysql" then mysql_service
75+
when "trilogy" then mariadb_service
76+
when "postgresql" then postgres_service
77+
end
78+
end
79+
80+
def postgres_service
81+
{
82+
"postgres" => {
83+
"image" => "postgres:16.1",
84+
"restart" => "unless-stopped",
85+
"networks" => ["default"],
86+
"volumes" => ["postgres-data:/var/lib/postgresql/data"],
87+
"environment" => {
88+
"POSTGRES_USER" => "postgres",
89+
"POSTGRES_PASSWORD" => "postgres"
90+
}
91+
}
92+
}
93+
end
94+
95+
def mysql_service
96+
{
97+
"mysql" => {
98+
"image" => "mysql/mysql-server:8.0",
99+
"restart" => "unless-stopped",
100+
"environment" => {
101+
"MYSQL_ALLOW_EMPTY_PASSWORD" => true,
102+
"MYSQL_ROOT_HOST" => "%"
103+
},
104+
"volumes" => ["mysql-data:/var/lib/mysql"],
105+
"networks" => ["default"],
106+
}
107+
}
108+
end
109+
110+
def mariadb_service
111+
{
112+
"mariadb" => {
113+
"image" => "mariadb:10.5",
114+
"restart" => "unless-stopped",
115+
"networks" => ["dqefault"],
116+
"volumes" => ["mariadb-data:/var/lib/mysql"],
117+
"environment" => {
118+
"MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true,
119+
},
120+
}
121+
}
122+
end
123+
124+
def db_service_names
125+
["mysql", "mariadb", "postgres"]
126+
end
127+
end
128+
end
129+
end

railties/lib/rails/generators/rails/app/app_generator.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,14 @@ def vendor
266266
def config_target_version
267267
@config_target_version || Rails::VERSION::STRING.to_f
268268
end
269+
270+
def devcontainer
271+
empty_directory ".devcontainer"
272+
273+
template ".devcontainer/devcontainer.json"
274+
template ".devcontainer/Dockerfile"
275+
template ".devcontainer/compose.yaml"
276+
end
269277
end
270278

271279
module Generators
@@ -455,6 +463,11 @@ def create_storage_files
455463
build(:storage)
456464
end
457465

466+
def create_devcontainer_files
467+
return if skip_devcontainer? || options[:dummy_app]
468+
build(:devcontainer)
469+
end
470+
458471
def delete_app_assets_if_api_option
459472
if options[:api]
460473
remove_dir "app/assets"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
2+
ARG RUBY_VERSION=<%= devcontainer_ruby_version %>
3+
FROM mcr.microsoft.com/devcontainers/ruby:1-$RUBY_VERSION-bookworm
4+
5+
<%- unless options.skip_active_storage -%>
6+
# Install packages needed to build gems
7+
RUN apt-get update -qq && \
8+
apt-get install --no-install-recommends -y \
9+
libvips \
10+
# For video thumbnails
11+
ffmpeg \
12+
# For pdf thumbnails. If you want to use mupdf instead of poppler,
13+
# you can install the following packages instead:
14+
# mupdf mupdf-tools
15+
poppler-utils
16+
<%- end -%>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
services:
2+
rails-app:
3+
build:
4+
context: ..
5+
dockerfile: .devcontainer/Dockerfile
6+
7+
volumes:
8+
- ../..:/workspaces:cached
9+
10+
# Overrides default command so things don't shut down after the process ends.
11+
command: sleep infinity
12+
13+
networks:
14+
- default
15+
16+
# Uncomment the next line to use a non-root user for all processes.
17+
# user: vscode
18+
19+
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
20+
# (Adding the "ports" property to this file will not forward from a Codespace.)
21+
ports:
22+
- 45678:45678
23+
<%- if !devcontainer_dependencies.empty? -%>
24+
depends_on:
25+
<%- devcontainer_dependencies.each do |dependency| -%>
26+
- <%= dependency %>
27+
<%- end -%>
28+
<%- end -%>
29+
30+
<%- if depends_on_system_test? -%>
31+
selenium:
32+
image: seleniarm/standalone-chromium
33+
restart: unless-stopped
34+
networks:
35+
- default
36+
<%- end -%>
37+
38+
<%- if devcontainer_needs_redis? -%>
39+
redis:
40+
image: redis:7.2
41+
restart: unless-stopped
42+
networks:
43+
- default
44+
volumes:
45+
- redis-data:/data
46+
<%- end -%>
47+
48+
<%= devcontainer_db_service_yaml(indentation: 4) %>
49+
50+
<%- if !devcontainer_volumes.empty? -%>
51+
volumes:
52+
<%- devcontainer_volumes.each do |volume| -%>
53+
<%= volume %>:
54+
<%- end -%>
55+
<%- end -%>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
2+
// README at: https://github.com/devcontainers/templates/tree/main/src/ruby
3+
{
4+
"name": "<%= app_name %>",
5+
"dockerComposeFile": "compose.yaml",
6+
"service": "rails-app",
7+
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
8+
9+
// Features to add to the dev container. More info: https://containers.dev/features.
10+
"features": {
11+
"ghcr.io/devcontainers/features/github-cli:1": {}
12+
},
13+
14+
<%- if !devcontainer_variables.empty? -%>
15+
"containerEnv": {
16+
<%= devcontainer_variables.map { |key, value| "\"#{key}\": \"#{value}\"" }.join(",\n ") %>
17+
},
18+
<%- end -%>
19+
20+
// Features to add to the dev container. More info: https://containers.dev/features.
21+
// "features": {},
22+
23+
// Use 'forwardPorts' to make a list of ports inside the container available locally.
24+
// "forwardPorts": [],
25+
26+
// Configure tool-specific properties.
27+
// "customizations": {},
28+
29+
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
30+
// "remoteUser": "root",
31+
32+
// Use 'postCreateCommand' to run commands after the container is created.
33+
"postCreateCommand": "bin/setup"
34+
}

0 commit comments

Comments
 (0)