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 <rafael@franca.dev>
This commit is contained in:
Andrew Novoselac 2024-01-29 16:13:02 -05:00 committed by Rafael Mendonça França
parent 30506d2ef3
commit c90a8701e5
No known key found for this signature in database
GPG Key ID: FC23B6D0F1EEE948
22 changed files with 761 additions and 6 deletions

View File

@ -161,7 +161,7 @@ module ActionDispatch
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:)

View File

@ -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.

View File

@ -23,6 +23,7 @@ module Rails
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

View File

@ -13,6 +13,7 @@ module Rails
module Generators
class AppBase < Base # :nodoc:
include Database
include Devcontainer
include AppName
NODE_LTS_VERSION = "18.15.0"
@ -109,6 +110,9 @@ module Rails
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"
@ -400,6 +404,10 @@ module Rails
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

View File

@ -90,6 +90,14 @@ module Rails
"/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

View File

@ -0,0 +1,129 @@
# frozen_string_literal: true
module Rails
module Generators
module Devcontainer
private
def devcontainer_ruby_version
gem_ruby_version.to_s.match(/^\d+\.\d+/).to_s
end
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

View File

@ -266,6 +266,14 @@ module Rails
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
@ -455,6 +463,11 @@ module Rails
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"

View File

@ -0,0 +1,16 @@
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=<%= devcontainer_ruby_version %>
FROM mcr.microsoft.com/devcontainers/ruby:1-$RUBY_VERSION-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 -%>

View File

@ -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 -%>

View File

@ -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"
}

View File

@ -1,6 +1,6 @@
# syntax = docker/dockerfile:1
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=<%= gem_ruby_version %>
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base

View File

@ -18,7 +18,7 @@ default: &default
<% if mysql_socket -%>
socket: <%= mysql_socket %>
<% else -%>
host: localhost
host: <%= mysql_database_host %>
<% end -%>
development:

View File

@ -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

View File

@ -18,7 +18,7 @@ default: &default
<% if mysql_socket -%>
socket: <%= mysql_socket %>
<% else -%>
host: localhost
host: <%= mysql_database_host %>
<% end -%>
development:

View File

@ -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

View File

@ -8,6 +8,7 @@ module Rails
module System
class ChangeGenerator < Base # :nodoc:
include Database
include Devcontainer
include AppName
class_option :to, required: true,
@ -54,6 +55,14 @@ module Rails
end
end
def edit_devcontainer_files
devcontainer_path = File.expand_path(".devcontainer", destination_root)
return unless File.exist?(devcontainer_path)
edit_devcontainer_json
edit_compose_yaml
end
private
def all_database_gems
DATABASES.map { |database| gem_for_database(database) }
@ -88,6 +97,59 @@ module Rails
gem_name_and_version.map! { |segment| "\"#{segment}\"" }
"gem #{gem_name_and_version.join(", ")}"
end
def edit_devcontainer_json
devcontainer_json_path = File.expand_path(".devcontainer/devcontainer.json", destination_root)
return unless File.exist?(devcontainer_json_path)
container_env = JSON.parse(File.read(devcontainer_json_path))["containerEnv"]
db_name = db_name_for_devcontainer
if container_env["DB_HOST"]
if db_name
container_env["DB_HOST"] = db_name
else
container_env.delete("DB_HOST")
end
else
if db_name
container_env["DB_HOST"] = db_name
end
end
new_json = JSON.pretty_generate(container_env, indent: " ", object_nl: "\n ")
gsub_file(".devcontainer/devcontainer.json", /("containerEnv"\s*:\s*){[^}]*}/, "\\1#{new_json}")
end
def edit_compose_yaml
compose_yaml_path = File.expand_path(".devcontainer/compose.yaml", destination_root)
return unless File.exist?(compose_yaml_path)
compose_config = YAML.load_file(compose_yaml_path)
db_service_names.each do |db_service_name|
compose_config["services"].delete(db_service_name)
compose_config["volumes"]&.delete("#{db_service_name}-data")
compose_config["services"]["rails-app"]["depends_on"]&.delete(db_service_name)
end
db_service = db_service_for_devcontainer
if db_service
compose_config["services"].merge!(db_service)
compose_config["volumes"] = { db_volume_name_for_devcontainer => nil }.merge(compose_config["volumes"] || {})
compose_config["services"]["rails-app"]["depends_on"] = [
db_name_for_devcontainer,
compose_config["services"]["rails-app"]["depends_on"]
].flatten.compact
end
compose_config.delete("volumes") unless compose_config["volumes"]&.any?
compose_config["services"]["rails-app"].delete("depends_on") unless compose_config["services"]["rails-app"]["depends_on"]&.any?
File.write(compose_yaml_path, compose_config.to_yaml)
end
end
end
end

View File

@ -0,0 +1,14 @@
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

View File

@ -0,0 +1,45 @@
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
depends_on:
- selenium
- redis
selenium:
image: seleniarm/standalone-chromium
restart: unless-stopped
networks:
- default
redis:
image: redis:7.2
restart: unless-stopped
networks:
- default
volumes:
- redis-data:/data
volumes:
redis-data:

View File

@ -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": "tmp",
"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": {}
},
"containerEnv": {
"CAPYBARA_SERVER_PORT": "45678",
"SELENIUM_HOST": "selenium",
"REDIS_URL": "redis://redis:6379/1"
},
// 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"
}

View File

@ -5,6 +5,9 @@ require "rails/generators/rails/app/app_generator"
require "generators/shared_generator_tests"
DEFAULT_APP_FILES = %w(
.devcontainer/Dockerfile
.devcontainer/compose.yaml
.devcontainer/devcontainer.json
.dockerignore
.git
.gitattributes
@ -1005,11 +1008,17 @@ class AppGeneratorTest < Rails::Generators::TestCase
def test_inclusion_of_ruby_version
run_generator
ruby_version = "#{Gem::Version.new(Gem::VERSION) >= Gem::Version.new("3.3.13") ? Gem.ruby_version : RUBY_VERSION}"
assert_file "Gemfile" do |content|
assert_match(/ruby "#{Gem::Version.new(Gem::VERSION) >= Gem::Version.new("3.3.13") ? Gem.ruby_version : RUBY_VERSION}"/, content)
assert_match("ruby \"#{ruby_version}\"", content)
end
assert_file ".devcontainer/Dockerfile" do |content|
minor_ruby_version = ruby_version.match(/^\d+\.\d+/).to_s
assert_match(/ARG RUBY_VERSION=#{minor_ruby_version}$/, content)
end
assert_file "Dockerfile" do |content|
assert_match(/ARG RUBY_VERSION=#{Gem::Version.new(Gem::VERSION) >= Gem::Version.new("3.3.13") ? Gem.ruby_version : RUBY_VERSION}/, content)
assert_match(/ARG RUBY_VERSION=#{ruby_version}/, content)
end
assert_file ".ruby-version" do |content|
if ENV["RBENV_VERSION"]
@ -1229,6 +1238,185 @@ class AppGeneratorTest < Rails::Generators::TestCase
assert_file "config/application.rb", /^module MyApp$/
end
def test_devcontainer
run_generator [destination_root, "--name=my-app"]
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"name": "my_app"/, content)
assert_match(/"REDIS_URL": "redis:\/\/redis:6379\/1"/, content)
assert_match(/"CAPYBARA_SERVER_PORT": "45678"/, content)
assert_match(/"SELENIUM_HOST": "selenium"/, content)
end
assert_file(".devcontainer/Dockerfile") do |content|
assert_match(/libvips/, content)
assert_match(/ffmpeg/, content)
assert_match(/poppler-utils/, content)
end
assert_compose_file do |compose_config|
expected_rails_app_config = {
"build" => {
"context" => "..",
"dockerfile" => ".devcontainer/Dockerfile"
},
"volumes" => ["../..:/workspaces:cached"],
"command" => "sleep infinity",
"networks" => ["default"],
"ports" => ["45678:45678"],
"depends_on" => ["selenium", "redis"]
}
assert_equal expected_rails_app_config, compose_config["services"]["rails-app"]
expected_selenium_conifg = {
"image" => "seleniarm/standalone-chromium",
"restart" => "unless-stopped",
"networks" => ["default"]
}
assert_equal expected_selenium_conifg, compose_config["services"]["selenium"]
expected_redis_config = {
"image" => "redis:7.2",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["redis-data:/data"]
}
assert_equal expected_redis_config, compose_config["services"]["redis"]
assert_equal ["redis-data"], compose_config["volumes"].keys
end
end
def test_devcontainer_no_redis_skipping_action_cable_and_active_job
run_generator [ destination_root, "--skip-action-cable", "--skip-active-job" ]
assert_compose_file do |compose_config|
assert_not_includes compose_config["services"]["rails-app"]["depends_on"], "redis"
assert_nil compose_config["services"]["redis"]
assert_nil compose_config["volumes"]
end
end
def test_devonctainer_postgresql
run_generator [ destination_root, "-d", "postgresql" ]
assert_compose_file do |compose_config|
assert_includes compose_config["services"]["rails-app"]["depends_on"], "postgres"
expected_postgres_config = {
"image" => "postgres:16.1",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["postgres-data:/var/lib/postgresql/data"],
"environment" => {
"POSTGRES_USER" => "postgres",
"POSTGRES_PASSWORD" => "postgres"
}
}
assert_equal expected_postgres_config, compose_config["services"]["postgres"]
assert_includes compose_config["volumes"].keys, "postgres-data"
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"DB_HOST": "postgres"/, content)
end
assert_file("config/database.yml") do |content|
assert_match(/host: <%= ENV\["DB_HOST"\] %>/, content)
end
end
def test_devonctainer_mysql
run_generator [ destination_root, "-d", "mysql" ]
assert_compose_file do |compose_config|
assert_includes compose_config["services"]["rails-app"]["depends_on"], "mysql"
expected_mysql_config = {
"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"],
}
assert_equal expected_mysql_config, compose_config["services"]["mysql"]
assert_includes compose_config["volumes"].keys, "mysql-data"
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"DB_HOST": "mysql"/, content)
end
assert_file("config/database.yml") do |content|
assert_match(/host: <%= ENV.fetch\("DB_HOST"\) \{ "localhost" } %>/, content)
end
end
def test_devonctainer_mariadb
run_generator [ destination_root, "-d", "trilogy" ]
assert_compose_file do |compose_config|
assert_includes compose_config["services"]["rails-app"]["depends_on"], "mariadb"
expected_mariadb_config = {
"image" => "mariadb:10.5",
"restart" => "unless-stopped",
"networks" => ["dqefault"],
"volumes" => ["mariadb-data:/var/lib/mysql"],
"environment" => {
"MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true,
},
}
assert_equal expected_mariadb_config, compose_config["services"]["mariadb"]
assert_includes compose_config["volumes"].keys, "mariadb-data"
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"DB_HOST": "mariadb"/, content)
end
assert_file("config/database.yml") do |content|
assert_match(/host: <%= ENV.fetch\("DB_HOST"\) \{ "localhost" } %>/, content)
end
end
def test_devcontainer_no_selenium_when_skipping_system_test
run_generator [ destination_root, "--skip-system-test" ]
assert_compose_file do |compose_config|
assert_not_includes compose_config["services"]["rails-app"]["depends_on"], "selenium"
assert_not_includes compose_config["services"].keys, "selenium"
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_compose_file do |compose_config|
assert_not_includes compose_config["services"]["rails-app"].keys, "depends_on"
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/compose.yaml")
end
private
def assert_node_files
assert_file ".node-version" do |content|

View File

@ -17,6 +17,7 @@ module Rails
ENTRY
copy_dockerfile
copy_devcontainer_files
end
test "change to invalid database" do
@ -50,6 +51,28 @@ module Rails
assert_match "build-essential git libpq-dev", content
assert_match "curl libvips postgresql-client", content
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"DB_HOST": "postgres"/, content)
end
assert_compose_file do |compose_config|
assert_includes compose_config["services"]["rails-app"]["depends_on"], "postgres"
expected_postgres_config = {
"image" => "postgres:16.1",
"restart" => "unless-stopped",
"networks" => ["default"],
"volumes" => ["postgres-data:/var/lib/postgresql/data"],
"environment" => {
"POSTGRES_USER" => "postgres",
"POSTGRES_PASSWORD" => "postgres"
}
}
assert_equal expected_postgres_config, compose_config["services"]["postgres"]
assert_includes compose_config["volumes"].keys, "postgres-data"
end
end
test "change to mysql" do
@ -69,6 +92,28 @@ module Rails
assert_match "build-essential default-libmysqlclient-dev git", content
assert_match "curl default-mysql-client libvips", content
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"DB_HOST": "mysql"/, content)
end
assert_compose_file do |compose_config|
assert_includes compose_config["services"]["rails-app"]["depends_on"], "mysql"
expected_postgres_config = {
"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"],
}
assert_equal expected_postgres_config, compose_config["services"]["mysql"]
assert_includes compose_config["volumes"].keys, "mysql-data"
end
end
test "change to sqlite3" do
@ -88,6 +133,10 @@ module Rails
assert_match "build-essential git", content
assert_match "curl libsqlite3-0 libvips", content
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_no_match(/"DB_HOST"/, content)
end
end
test "change to trilogy" do
@ -108,6 +157,27 @@ module Rails
assert_match "curl libvips", content
assert_no_match "default-libmysqlclient-dev", content
end
assert_file(".devcontainer/devcontainer.json") do |content|
assert_match(/"DB_HOST": "mariadb"/, content)
end
assert_compose_file do |compose_config|
assert_includes compose_config["services"]["rails-app"]["depends_on"], "mariadb"
expected_postgres_config = {
"image" => "mariadb:10.5",
"restart" => "unless-stopped",
"networks" => ["dqefault"],
"volumes" => ["mariadb-data:/var/lib/mysql"],
"environment" => {
"MARIADB_ALLOW_EMPTY_ROOT_PASSWORD" => true,
},
}
assert_equal expected_postgres_config, compose_config["services"]["mariadb"]
assert_includes compose_config["volumes"].keys, "mariadb-data"
end
end
test "change from versioned gem to other versioned gem" do
@ -124,6 +194,23 @@ module Rails
assert_match 'gem "mysql2", "~> 0.5"', content
end
end
test "change from db with devcontainer service to one without" do
copy_minimal_devcontainer_compose_file
run_generator ["--to", "mysql"]
run_generator ["--to", "sqlite3", "--force"]
assert_file(".devcontainer/devcontainer.json") do |content|
assert_no_match(/"DB_HOST"/, content)
end
assert_compose_file do |compose_config|
assert_not_includes compose_config["services"]["rails-app"].keys, "depends_on"
assert_not_includes compose_config["services"].keys, "mysql"
assert_not_includes compose_config.keys, "volumes"
end
end
end
end
end

View File

@ -82,6 +82,25 @@ module GeneratorsTestHelper
File.write File.join(destination, "Dockerfile"), dockerfile
end
def copy_devcontainer_files
destination = File.join(destination_root, ".devcontainer")
mkdir_p(destination)
devcontainer_json = File.read(File.expand_path("../fixtures/.devcontainer/devcontainer.json", __dir__))
File.write File.join(destination, "devcontainer.json"), devcontainer_json
compose_yaml = File.read(File.expand_path("../fixtures/.devcontainer/compose.yaml", __dir__))
File.write File.join(destination, "compose.yaml"), compose_yaml
end
def copy_minimal_devcontainer_compose_file
destination = File.join(destination_root, ".devcontainer")
mkdir_p(destination)
compose_yaml = File.read(File.expand_path("../fixtures/.devcontainer/compose-minimal.yaml", __dir__))
File.write File.join(destination, "compose.yaml"), compose_yaml
end
def evaluate_template(file, locals = {})
erb = ERB.new(File.read(file), trim_mode: "-", eoutvar: "@output_buffer")
context = Class.new do
@ -97,6 +116,12 @@ module GeneratorsTestHelper
erb.result()
end
def assert_compose_file
assert_file ".devcontainer/compose.yaml" do |content|
yield YAML.load(content)
end
end
private
def gemfile_locals
{